feat(token-2022/transfer-hook/hello-world): add pinocchio example - #709
Conversation
Ports the minimal transfer-hook example to Pinocchio, covering the three
instructions the Anchor version exposes:
- initialize: creates a Token-2022 mint naming this program as its transfer
hook, by hand-building the TransferHookExtension(Initialize) and
InitializeMint2 CPIs, then reading the extension back to confirm it.
- initialize_extra_account_meta_list: creates the
[b"extra-account-metas", mint] PDA holding the serialized, empty
ExtraAccountMetaList that Token-2022 reads before every transfer.
- Execute: the interface instruction Token-2022 CPIs during a transfer.
It checks the source account's TransferHookAccount `transferring` flag,
which is what stops the hook being invoked outside a transfer.
The two interface discriminators are fixed by spl-transfer-hook-interface
(the first eight bytes of sha256("spl-transfer-hook-interface:<ix>")), so
they are matched before this example's own one-byte tag. There is no
Pinocchio crate for Token-2022, so the mint and token-account TLV
extension area is walked by a small bounds-checked reader rather than
depending on spl-token-2022.
LiteSVM tests cover mint creation and its decoded extension, the meta list
bytes, a real transfer that asserts the hook logged from inside Token-2022's
CPI, and a direct Execute call rejected with IsNotCurrentlyTransferring.
Greptile SummaryThe PR adds a Pinocchio implementation of the minimal Token-2022 transfer-hook example alongside the Anchor version.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the current source-account and mint-hook checks address both previously reported invocation-boundary issues. Important Files Changed
Reviews (5): Last reviewed commit: "token-2022 transfer-hook hello-world: cr..." | Re-trigger Greptile |
check_is_transferring parsed whatever account the caller passed as the source, so the `transferring` flag it relied on was only as trustworthy as that account. Anyone may call Execute directly, and an account built by the caller with the right bytes at the right offsets reads as a type-15 TransferHookAccount extension with transferring = 1 — passing the guard and reaching the hook body outside any transfer. The flag is only meaningful if Token-2022 wrote it, so the source account is now required to be owned by Token-2022 and to name the mint it was invoked with. A Token-2022 account can only reference a real Token-2022 mint, so the pair pins the source to an account Token-2022 itself produced. This is the guarantee the Anchor version gets from InterfaceAccount<TokenAccount> and its token::mint constraint. Adds a test that forges an account carrying transferring = 1 for the real mint under a non-Token-2022 owner; it is rejected with InvalidSourceAccount. Against the previous program that same transaction succeeds.
…ports CI's tsc --noEmit step rejected the raw bigint returned by minimumBalanceForRentExemption where EncodedAccount expects the branded Lamports type.
…ram as its hook Execute only checked that the source account was a genuine Token-2022 account mid-transfer. A mint configured with a *different* hook program is mid-transfer too while that program runs, and that program can CPI here with the genuine source account, passing every check and running the hook body outside its configured path. Read the hook program back off the mint's TransferHook extension and reject anything that is not this program. Covered by a test that is verified to succeed without the check.
|
Audit follow-up from #714: every PDA this example creates has a publicly derivable address, and Fixed here too. PDA creation now goes through a Covered by pre-funding each derivable address with one lamport in the setup test before the creating instruction runs. |
Ports the minimal transfer-hook example to Pinocchio, alongside the existing Anchor version.
What it does
Three instructions, matching the Anchor example:
initialize— creates a Token-2022 mint that names this program as its transfer hook, hand-building theTransferHookExtension(Initialize)andInitializeMint2CPIs, then reading the extension back to confirm the mint was configured as intended.initialize_extra_account_meta_list— creates the[b"extra-account-metas", mint]PDA holding the serializedExtraAccountMetaList. This example resolves no extra accounts, so the list is a fixed 16 bytes: theExecutediscriminator, au32length of 4, and au32count of 0.Execute— the interface instruction Token-2022 CPIs during every transfer. It checks the source account'sTransferHookAccounttransferringflag, which is what stops the hook from being invoked directly, outside a transfer.Notes
spl-transfer-hook-interface(the first eight bytes ofsha256("spl-transfer-hook-interface:<ix>")), so they are matched before this example's own one-byte tag forinitialize.token2022.rsrather than depending onspl-token-2022. The same offsets serve mints and token accounts, since Token-2022 pads mints toAccount::LEN.Tests
LiteSVM, 5 passing:
TransferHookextension decodes to the expected authority and programExtraAccountMetaListaccount holds exactly the expected 16 bytesTransferCheckedmoves tokens and the hook logsHello Transfer Hook!from inside Token-2022's CPI, so the assertion fails if the hook is silently bypassedExecutecall is rejected withIsNotCurrentlyTransferring(custom error0x0), asserted on the reason rather than just on failure